Skip to content

video: support five streams and improve per-entry access controls - #43

Merged
tridge merged 24 commits into
ArduPilot:mainfrom
tridge:pr-video-5-slots
Sep 7, 2026
Merged

video: support five streams and improve per-entry access controls#43
tridge merged 24 commits into
ArduPilot:mainfrom
tridge:pr-video-5-slots

Conversation

@tridge

@tridge tridge commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • expand per-entry video capacity from three streams to five while preserving the append-only keys.tdb layout
  • add a per-slot MAVLink-session publishing fallback for cameras that cannot present the configured publish password
  • expose the new slot policy in the web UI and improve password-field help rendering
  • ignore unsupported RTMP data streams instead of rejecting otherwise usable publishers
  • add per-entry Private, Login Required, and Public read-only log access with delete operations still restricted to owners/admins

Compatibility

Existing key records remain valid: slots four and five use fields appended after the established record layout, and the new access flags default existing entries to their previous private behavior.

Testing

  • make -j2
  • pytest -q tests/webadmin — 287 passed
  • pytest -q tests/test_video_schema.py tests/test_video_ports.py tests/test_video_rtsp.py — 84 passed

tridge added 8 commits August 18, 2026 13:28
An aircraft can carry more than three cameras, and one port carries one
stream, so the cap was the limit on how many a single entry could
proxy.

The three fields that hold per-slot state -- video_ports, video_flags
and video_rtmp_path -- all sit in the middle of the record, so none of
them could simply grow: every field after them would shift, every record
already on disk would be misparsed, and an older binary would read
garbage. The append-only contract at the top of keydb.h exists to make
that unnecessary, so slots 3 and 4 are carried in new fields appended
after reserved[], and accessors join the two halves. A record written
before they existed zero-extends into them, which reads as two unused
slots, so nothing needs converting and the live database keeps working.

video_flags could not be widened for the same reason, and it was already
full: three slot bytes plus the entry-wide byte is exactly 32 bits. A
fourth slot byte at shift 24 would have landed on the entry options --
which is what happened first time round, and is why there is now a test
that sets slots 3 and 4 to 0xFF and checks the audio flag survives.

The record grows 344 -> 456 bytes. Also fixes video_port_count()
tripping over a short list, which callers that build a KeyEntry by hand
were relying on not happening.

The README's video section had been pasted in three times: the edit that
added it replaced on "## Building", which matches three headings. Only
one copy remains.
Bit 3 of each slot's option byte, which was free, so no record growth
and no migration -- an existing record reads it clear, which is the
current behaviour.

It marks a slot whose publisher may be admitted by the entry's MAVLink
session even though a publish password is set. Some publishers cannot
present one: a camera speaking RTMP from its own firmware has nowhere
to put a credential unless its stream-key field tolerates a query, and
plain MPEG-TS over UDP never does. Without this an entry faced an
all-or-nothing choice between a password and those streams.
admit() gains the slot's session_ok bit. With it set and no credential
offered, admission falls through to the MAVLink-session path instead of
refusing; without it, nothing changes.

The fallback deliberately does not apply to a credential that was
offered and is wrong. Downgrading that to address matching would turn a
clear rejection into a silent weakening, so a typo cannot succeed on
the strength of the source address.

Five tests, of which two guard the behaviour being preserved: an
unflagged slot still refuses a session-only publisher, and a wrong
password is still refused on a flagged one. Verified RED by forcing the
bit false -- the three that assert the new path fail, the two guards
still pass.
Rendered in the existing per-slot options row and documented in the
template beside it, as the other three are. The forms.py tooltip check
exempts the per-slot booleans, so the guard that they really are
documented where they are rendered is extended to cover it.
Pasting into it with the tooltip showing wedges Chrome's renderer: the
tab stops responding to input entirely, and it does not recover. The
text it carried moves into the blurb above the form, which already
introduced the field, so nothing is lost.

The forms.py "every option is documented" guard exempts the field and
records why, so it cannot be quietly reinstated.
A tooltip over a password field wedges Chrome's renderer: the tab stops
accepting input and does not recover. Reproduced on the login form and
removed there; this covers the rest, which share the markup and so
presumably share the fault.

The help itself is worth keeping -- the publish-password text explains
that it replaces the address check, and the new-passphrase one that
blank means unchanged -- so the macro renders a password field's
description in flow as .field-hint rather than dropping it. The dotted
underline that advertises a tooltip goes with it, and aria-describedby
is now emitted only when there is something to point at.

Two guards: no password input on any page carries a .tip, and the text
still reaches the page. Both verified RED.
@tridge

tridge commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Deprecated — see below for the updated review after the harden-publish-session-fallback fix.

Previous review (2026-08-25, at head 1174f36)

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.
Full report (with the other DevCallEU PRs): https://uav.tridgell.net/DevCallReviews/2026_08_26/devcall_pr_reviews.html

Reviewed at head 1174f36b03 — verdict: COMMENT. Careful, security-conscious PR; the 5-slot ABI split, per-slot isolation and log gate are all sound. My own read first cleared it APPROVE (the admit() decision itself is correct); an independent cold security pass flagged the credential-presence handling, which I traced against source. Two of the cold pass's claims I refuted on inspection (see below), so the confirmed set is narrower than a raw cold read would suggest.

ISSUE — a supplied-but-wrong publish password can be read as "absent" and take the address fallback. admit() is correct — it returns BAD_PASSWORD before any session_ok consideration when a credential is supplied — but "credential absent" is computed upstream as *password != '\0' on a bare string with no explicit present-bit, and four paths collapse a supplied-wrong value to empty on a session_ok+non-bidi slot:

  • RTSP reads the credential with a single MSG_PEEK that doesn't wait for a complete request line (video.cpp:639-660) — withhold ?pw=wrong from the first segment and the rest is spliced to ffmpeg with no re-auth;
  • the publish path matches only a literal ?pw= (video.cpp:647), so /cam?mode=x&pw=wrong reads as no-credential (the robust HttpRequest::query() is used only on the viewer path);
  • http_url_decode permits %00 (httpreq.cpp:142), so ?pw=%00wrong is NUL-first and tests as absent;
  • RTMP split_credential lets the last parameter win (videortmp.cpp:296), so FPV?pw=wrong&pw= arrives empty.

Not an access-control bypass — every vector needs the attacker already at the authorized session address on a flag-enabled, non-bidi slot, where credential-free publish is intended — but it means the README's "a typo cannot quietly succeed on the strength of the address" isn't honoured. A uniform fix: represent presence with an explicit flag (independent of the decoded value), require a complete bounded request line before deciding, reject embedded NUL, and use the robust query parser on the publish path too.

ISSUE — signed-session fallback can never succeed (fails closed). ConnEntry.authenticated is read by the bidi gate (videoauth.cpp:70,99) but never written non-zero — the conn_write sites (supportproxy.cpp:1103,1132) zero-init and never copy is_authenticated() — so a KEY_FLAG_BIDI_SIGN slot always returns UNAUTH on the session path. Safe, but it contradicts the documented intent (conntdb.h:94-96): session_ok on a signed entry won't work as described.

NOTE — stale comments: keydb_lib.py still says the record is 344 bytes and set_video_ports() says "up to 3"; the implementation is correct at 456 bytes / 5 slots.

Refuted from the cold pass and not issues: the session-name collision check is not still bounded to 3 (every video-slot loop uses MAX_VIDEO_PORTS=5; the only literal 3 is KEY_VIDEO_PORTS_INLINE, the on-disk struct split, correct by design); and the RTMP unsupported-stream change only alters the ffmpeg -map (nothing leaks our side).

Verified clean: append-only ABI (old 344-byte records zero-extend so the new flag defaults off on migration), per-slot/per-entry isolation, port allocation stops correctly at 65535, and the log gate re-validates the viewer each request with traversal closed. Please confirm CI green before merge given this touches an auth path.

Preserve the distinction between absent and supplied publish credentials across RTSP and RTMP parsing, and export signed MAVLink authentication state for bidi session fallback. Update stale five-slot schema documentation and add regression coverage.
@tridge

tridge commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Deprecated — see below for the updated review at head 8e398f3b36.

Previous review (2026-08-25, at head 42209cf)

Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting.

Full report: https://uav.tridgell.net/DevCallReviews/2026_08_26/devcall_pr_reviews.html#prSupportProxy_43

Reviewed at head 42209cf0c7. Verdict: REQUEST CHANGES.

Re-reviewed at the same head as my 2026-08-25 comment; the auth/ABI work verified clean again (credential-presence bits end-to-end, 456-byte split-record ABI offsets, per-slot session_ok, RTSP request guarding), but the must-fix from that round is still unaddressed, so the verdict stands.

Must fix:

  • session.cpp:76 — SESSION_EXTS is still .tlog .bin .v1.ts .v2.ts .v3.ts; slots 4/5 write .v4.ts/.v5.ts (videorec.cpp:126) via the same session_unique_basename(), so basename_free() reports a name free while that name's v4/v5 files exist. Impact refined from the last round: actual truncation/append corruption cannot occur — .tlog/.bin are still in the set, and videorec opens O_EXCL and retries on EEXIST — but the one-session-one-basename contract the function's own comment states is broken: a new tlog/bin/video session can silently share a basename with an unrelated existing v4/v5 recording, corrupting session identity for the web UI grouping and the -N suffix scheme. Add the two extensions (or derive the list from KEY_MAX_VIDEO_PORTS). (link)

Should fix:

  • webadmin/logs.py:543 — admin_play_mp4 sits behind require_log_read, so on a LOG_ACCESS_PUBLIC entry an anonymous client reaches _remux_response (logs.py:376), which spawns one ffmpeg per request with no concurrency cap. ffmpeg is killed when the generator closes, and the shipped deployment caps the web server at 4 worker threads (start_webadmin.sh:44), so this is bounded — but 4 parallel slow anonymous readers monopolise the entire web capacity (and each holds an ffmpeg). Cap concurrent remuxes (global or per-IP) or keep play.mp4 login-gated. (link)

Notes (non-blocking):

  • videortsp.cpp:256 — -map 0 became -map 0:v:0 (+ optional 0:a?). A publish with no video stream at all (audio-only RTMP/RTSP), which -map 0 previously carried, now hard-fails with "Stream map '0:v:0' matches no streams" — same 0-KiB-slot symptom this change fixes for data streams. Almost certainly acceptable for a video proxy; worth one line in the commit message or a nicer log. (link)
  • keydb.h:207 — video_rtmp_path_size() is added but has no callers (promote_pending uses sizeof(ke_.video_rtmp_path[0]) directly, video.cpp:979). Drop it or use it. (link)

Previous round triage: SESSION_EXTS BUG — still open (impact narrowed on re-analysis: .tlog/.bin remain in the set and videorec opens O_EXCL, so no file corruption — it breaks the one-session-one-basename contract instead; still worth the two-line fix, or derive the list from KEY_MAX_VIDEO_PORTS). Anonymous ffmpeg remux ISSUE — still open (bounded by the 4-thread web server, so it monopolises web capacity rather than exhausting processes). All previously-RESOLVED credential/RTSP/export items re-verified as fixed.

tridge added 8 commits August 26, 2026 21:45
Reject duplicate RTMP connect properties, preserve the first credential across RTMP setup commands, and continue validating credentials on every RTSP request. Bound RTSP framing, reject ambiguous content lengths, and cover the reported downgrade cases with integration tests.
A per-slot flag that admits a publisher offering no credential with no
MAVLink-session check and no password. For a camera on its own link
whose telemetry does not pass through this proxy and which cannot
carry a password (plain MPEG-TS/UDP). Off by default; a password that
is offered and wrong is still refused.
A publisher sending rtph264pay/rtph265pay ! udpsink (or ffmpeg -f rtp)
to a video port was counted as bad datagrams: the UDP path only knew
MPEG-TS. Now an RTP datagram from the latched publisher is recognised,
the codec is read off the first unambiguous NAL header, and the
datagrams are forwarded to the same ffmpeg backend RTSP uses, fed an
SDP on stdin naming the codec and a loopback port. Its MPEG-TS output
goes through the normal ingest, so viewers and the recorder are
unchanged. Parameter sets must be in-band, as there is no SDP from the
sender.

The backend is told to bind 127.0.0.1 only -- the sdp demuxer's
default is 0.0.0.0, which would expose the port past admission -- and
start() waits for that bind so the first datagrams are not lost.
connections.tdb rows report such a publisher as RTP over UDP.
basename_free() listed .v1.ts to .v3.ts, so a name taken only by a
slot 4 or 5 recording was handed out again and two sessions shared a
basename. Derive the list from KEY_MAX_VIDEO_PORTS instead.
On a public-logs entry anyone can open play.mp4, and each stream holds
a gunicorn thread and an ffmpeg for as long as the client reads. With
four threads shipped, a few slow anonymous readers took the whole web
UI. Anonymous readers now get two concurrent remuxes and a 503 with
Retry-After beyond that; logged-in readers are unaffected.
@tridge tridge added AIReview Request an automated AI review; picked up by the reviewprs sweep and removed DevCallEU labels Sep 7, 2026
@tridge

tridge commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Deprecated — see below for the updated review at head 16f59e1fb5.

Previous review (2026-09-07, at head `8e398f3b36`)

Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting.

Full report: https://uav.tridgell.net/DevCallReviews/followups/2026_09_07_1655/devcall_pr_reviews.html#prSupportProxy-43

Reviewed at head 8e398f3b36. Verdict: REQUEST CHANGES.

Re-reviewed on request; my earlier comment above is superseded. All four findings from the last round (head 42209cf0c7) are addressed — but the fix for one of them introduces a new must-fix bug, and the new RTP path has one of its own.

Previous round

  1. SESSION_EXTS missing .v4.ts/.v5.ts — RESOLVED. d9dd7eb replaces the literal list with a loop over 1..KEY_MAX_VIDEO_PORTS, deriving it from the constant as suggested. Verified against the recorder's real format: videorec.cpp:126 writes "%s/%s.v%d.ts" with slot_ + 1, so slots 0–4 produce .v1.ts.v5.ts and the new loop covers exactly that, plus .tlog/.bin.
  2. Uncapped anonymous ffmpeg remux — ADDRESSED, but see BUG 1. The control is the right one (anonymous-only, admins unaffected) and works for GET; the HEAD path defeats it permanently.
  3. Unused video_rtmp_path_size() — RESOLVED. Removed; no references remain in any tracked file.
  4. -map 0:v:0 vs an audio-only publish — ADDRESSED as documentation. 8e398f3 adds a source comment, no behaviour change and no new log line. Fair for a non-blocking note. Worth knowing it now interacts with BUG 2 below: an audio-only publish is exactly a fast-failing ffmpeg.

Must fix

BUG — two anonymous HEAD requests permanently kill anonymous log playback, and leak two ffmpegs. The permit is taken at webadmin/logs.py:415 but released only inside generate()'s finally (logs.py:448). @route(methods=['GET']) also accepts HEAD, and Werkzeug's get_app_iter() substitutes an empty iterable for HEAD — so the generator is never started, and closing a never-started Python generator does not run its finally. Neither proc.kill() nor slot.release() ever runs. Reproduced against the real WSGI path (Werkzeug 3.1.8) with a faithful copy of _remux_response:

anon HEAD -> 200 | anon HEAD -> 200 | anon HEAD -> 503 | anon GET -> 503
ffmpeg spawned=2  killed=0

Since start_webadmin.sh:44 runs -w 1 -k gthread --threads 4 there is one process-wide semaphore, so curl -I twice against any LOG_ACCESS_PUBLIC entry's play.mp4 disables anonymous playback server-wide until restart; Retry-After: 5 never comes true. Both Codex passes reproduced this independently. Fix: one idempotent cleanup called from both the generator's finally and resp.call_on_close(), with slot.release() in its own finally so an exception from kill()/wait() cannot strand the permit — I verified call_on_close does fire on the HEAD path and fully fixes it. The existing cap tests use client.get(), which consumes the body; a HEAD case would have caught this. (logs.py#L415)

BUG — a dead RTP backend is never cleaned up, leaving a permanent 100%-CPU epoll spin. tick() does if (s.rtsp.running() && s.rtsp.reap()) close_rtsp(...), but reap() sets pid_ = -1, so by the time close_rtsp() runs running() is already false. Its guard is if (!s.rtsp.running() && s.rtsp_client_fd < 0 && !s.rtmp) return; — and an RTP publisher is the first kind with neither a TCP client fd nor an RTMP session, so it returns early and nothing is cleaned: media_fd stays registered in epoll and open, has_pub stays set, rtsp.stop() is never called, viewers are never ended. RTSP keeps rtsp_client_fd >= 0 and RTMP keeps s.rtmp, which is why this is new to the RTP path.

This is deterministic, not a race: latch_publisher() sets last_tick_ = 0, so tick() runs at the bottom of the very same loop iteration that started the backend — before any epoll_wait can observe the hangup — and a fast-failing ffmpeg (not installed, or an audio-only publish hitting 0:v:0) has already exited during the 3 s readiness wait. Thereafter every epoll_wait returns EPOLLHUP on the orphaned media_fd, dispatch calls close_rtsp(), the same guard returns immediately, and it spins forever. The 10 s idle path later clears has_pub but never removes or closes media_fd, so the spin and the fd leak outlive the publisher. (video.cpp#L1639)

Should fix

RtspBackend::start() reports success for an RTP backend that is already dead. The readiness loop polls loopback_udp_bound() then return true unconditionally — it never calls reap() and does not fail on timeout. The RTSP path just below (videortsp.cpp:434) does the opposite and returns false with "exited before it listened (is ffmpeg installed?)". Because RTP always returns true, the VIDEO_RTP_RETRY_S backoff at video.cpp:614 is never armed for the one failure it exists for, and the caller goes on to register a dead backend's media_fd — which is what makes BUG 2 reachable. Separately, this wait is synchronous in the single epoll loop, so a first RTP datagram stalls all five slots (including slots with no open_publish) for up to 3 s. (videortsp.cpp#L396)

Notes (non-blocking)

  • video.cpp:591 — the RTP detection latch is sticky. Once one datagram parses as RTP, s.rtp_seen > 0 sends every later datagram to ingest_rtp(), which drops non-RTP — so one stray or spoofed RTP-shaped packet from the publisher's address permanently kills an established MPEG-TS stream, and it cannot self-heal (a publisher that keeps sending refreshes pub_last, so the idle release never fires). The detector itself is sound — I compiled rtp_payload_offset() and tested it against real MPEG-TS (0x47), SRT data and all SRT control types 0–8 and 0x7FFF with no misdetection — so this needs a deliberate or malformed packet. Cheap guard: only consider RTP when the TS scanner has not already anchored (!s.had_anchor). (video.cpp#L591)
  • video.cpp:517 — buf[2048] with plain recvfrom() silently truncates a larger datagram. TS resynchronises; the RTP path forwards the truncated prefix as a whole packet. Default payloaders sit near the 1400-byte MTU so this is unlikely, but rtph264pay mtu= is configurable. MSG_TRUNC and drop/count would close it. (video.cpp#L517)
  • video.cpp:611 — the H.264 fallback cannot correct itself. After 64 inconclusive datagrams the backend starts as H.264, and once running ingest_udp() forwards straight to send_rtp() with no further inspection, so a later VPS/SPS changes nothing. Needs 64 consecutive HEVC packets with no FU/AP/parameter set, i.e. small single-NAL slices only — a low-bitrate corner. (video.cpp#L611)
  • Makefile:82 — session.o: session.cpp session.h but session.cpp now includes keydb.h. Every other object here lists its headers explicitly (videoauth.o and cleanup.o both name keydb.h), so this is against the file's own convention. Concretely: changing KEY_MAX_VIDEO_PORTS would not rebuild session.o, silently leaving basename_free() on the old slot count — reintroducing exactly what d9dd7eb just fixed. (Makefile#L82)
  • keydb.h:96 — comment precision only. "A password that is offered and wrong is still refused" (repeated in the README) is unconditional, but admit() only judges the credential inside if (have_pw); with no publish password set and open_publish on, a supplied password is accepted unchecked. Vacuous rather than unsafe — there is nothing to compare against and the slot is deliberately open — but worth narrowing to "when a publish password is set". (keydb.h#L96)

Verified clean

Recorded so the clearances are on the record too: admit() traced branch by branch — with open_publish clear every previous branch is preserved, and with it set a supplied-and-wrong password on an entry that has a publish password still returns BAD_PASSWORD before the open-slot return; all three admit() callers are publisher paths, so the flag cannot widen viewing. Per-slot isolation and the flag ABI (each slot's field is 8 bits, five now used, so bit 4 collides with nothing — in particular not the entry-wide byte at shift 24). Web form round-trip sets/clears only the mapped bit and preserves the rest, with the Python constant matching the C++. RTP parsing bounds (every CSRC count and extension length; the two codec case sets provably disjoint). No ffmpeg argument injection — the only remote-influenced SDP inputs are a choice between two string literals and a payload type already constrained to 96–127, and -localaddr 127.0.0.1 correctly stops the sdp demuxer binding 0.0.0.0. fd lifecycle in start(). Idle teardown of a live RTP backend (it is only the post-reap() path that fails). CI green at this head.

A GET route also answers HEAD, and Werkzeug never starts the body
generator for one, so cleanup that lived only in the generator's
finally never ran: each anonymous HEAD leaked an ffmpeg and one of the
two anonymous permits, and two of them disabled anonymous playback
until restart. Cleanup is now idempotent and also hooked on
call_on_close, with the permit released in its own finally.
close_rtsp() returned early once reap() had marked the backend gone,
because an RTP publisher has neither a client fd nor an RTMP session:
the media fd stayed in epoll and spun on EPOLLHUP. The guard now also
keys on the media fd. RtspBackend::start() for RTP reaps during the
readiness wait and fails on exit or timeout, as the RTSP path does, so
the retry backoff is armed and a dead backend is never registered.

Also: RTP is only considered before any TS has been scanned, so a
stray RTP-shaped datagram cannot abandon a live MPEG-TS stream, and an
oversize datagram is dropped (MSG_TRUNC) rather than forwarded
truncated.
It is judged against the entry's publish password, so with none set
and open_publish on, a supplied one is accepted unchecked.
@tridge

tridge commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Automated review note — AI-generated (Claude), validated against the live diff (Claude + Codex cross-checked). Please sanity-check before acting.

Full report: https://uav.tridgell.net/DevCallReviews/followups/2026_09_07_1910/devcall_pr_reviews.html#prSupportProxy-43

Reviewed at head 16f59e1fb5. Verdict: COMMENT (was REQUEST CHANGES).

Re-reviewed on request; my earlier comment above is superseded. All four findings from the last round (head 8e398f3b36) are resolved, one commit each. Two new items below, both should-fix rather than blocking — plus a correction I owe you on my own previous description of one bug.

Everything load-bearing here was checked by mutation: each fix reverted, rebuilt, and the behaviour re-measured. That is also how I found the one weak spot (a new regression test that still passes without its fix).

Previous round — all resolved

  1. HEAD leaks the remux permit and the ffmpeg — RESOLVED. c621f90 is exactly the shape I suggested: one idempotent cleanup() reached from both the generator's finally and resp.call_on_close(), with slot.release() in its own finally. Ran the real WSGI path (Werkzeug 3.1.8) against a faithful copy of _remux_response, old code vs new:
    old:  HEAD -> 200 | HEAD -> 200 | HEAD -> 503 | GET -> 503   children alive=2  permits_free=0
    new:  HEAD -> 200 | HEAD -> 200 | HEAD -> 200 | GET -> 200   children alive=0  permits_free=2
    
    The server-wide lockout is gone and no ffmpeg survives. The new test_head_requests_release_the_permit_and_the_ffmpeg covers the exact gap the old tests missed.
  2. Dead RTP backend never cleaned up — RESOLVED. Measured on a real build against a backend that dies: pre-fix the slot churns a fresh ffmpeg roughly every 3 s and peaks at 12 fds in the video child; post-fix it settles at 8 with one stable backend.
  3. RtspBackend::start() reporting success for a dead backend — RESOLVED, and properly pinned. I checked the one thing that would have made this dangerous: stop() guards on pid_ > 0, so calling it after reap() set pid_ = -1 cannot reach kill(-1, …). Running the new tests against the pre-fix code, test_backend_that_dies_before_binding_is_a_failed_start fails — so this one is genuinely protected.
  4. Sticky RTP latch, buf[2048] truncation, Makefile dep, and the keydb.h wording — all RESOLVED. On the RTP latch I checked the thing that would have made it a regression: the guard must reset between publishers, and it does — latch_publisher() assigns a fresh TSScanner() and end_stream() calls scanner.reset(), so an RTP publisher following an MPEG-TS one is still classified. Also confirmed feed() only increments packets once anchored on a confirmed sync pair, so ordinary noise cannot latch it shut. The comment fix landed consistently in all four places the claim appears (keydb.h, videoauth.h, README.md, and the UI tooltip).

Still open from before, unchanged and still non-blocking: the H.264 fallback cannot self-correct (video.cpp#L611). Re-raising only so it is not silently dropped.

Correction to my previous comment

I described finding 2's symptom as "a permanent 100%-CPU epoll spin" with an fd leak that "outlives the publisher". Measured against the pre-fix build, that is not what happens, and I should not have stated it that confidently. The video child used only 0.05 s of CPU across the whole scenario, and the slot does recover — the hangup on the orphaned media pipe reaches close_rtsp() while pid_ is still set, so it tears down and logs RTP publisher gone (connection closed). The real pre-fix symptom is backend churn plus a per-cycle fd leak, not a spin. Your fix is still right; my description of the damage was wrong.

New — should fix

A partial RTSP request line spins the video child at ~75% of a core for 2 s, pre-auth. The guard returns true without consuming when a recognised method arrives with no \n yet. The peek is MSG_PEEK, the fd is armed level-triggered EPOLLIN, and the connection stays in VV_DETECT — so epoll_wait re-fires on the same unread bytes until VIDEO_DETECT_SILENCE_S (2 s). CPU consumed by the video child over a 2.5 s window:

idle (connect, send nothing)          0 ticks   ( 0% of a core)
complete RTSP line                    0 ticks   ( 0% of a core)
partial "OPTIONS ..."  (no newline) 180 ticks   (72% of a core)
partial "DESCRIBE ..." (no newline) 185 ticks   (74% of a core)

Scope cuts both ways, so both halves are worth stating. The spin is pre-existing: a payload matching no detector at all (ZZZZ) burns 149 ticks at this head and 142 with this branch reverted — the PR did not create it. But reverting just this branch takes the partial-RTSP case from 180 ticks to 0, so the PR does widen it to a new and entirely legitimate input: a publisher whose OPTIONS is split across TCP segments. On a LAN the next segment arrives in milliseconds and nobody notices; a peer that stalls mid-line costs a core for two seconds, repeatably. Cheapest fix is to stop re-arming read interest while a connection sits in detect with bytes it deliberately has not consumed (or consume-and-buffer the partial line and forward on classification) — which fixes the pre-existing junk-input case at the same time. (videoview.cpp#L207)

The Login Required share link loses its destination. require_log_read redirects to url_for('auth.login', next=request.path), but login() reads next from request.args — the query string of the POST — and login.html renders action="{{ url_for('auth.login') }}", which has no query string. So next is dropped at the form and a successful login goes to url_for('index'). Walked end-to-end through your own test client:

1. anonymous GET /admin/logs/14555/  -> 302 /login?next=/admin/logs/14555/
2. login page renders form action='/login'        <-- next dropped here
3. POST /login                       -> 302 /
4. RESULT: wanted '/admin/logs/14555/', got '/'

That is the primary use of LOG_ACCESS_LOGIN_REQUIRED — send someone a link, they log in, they should land on the logs. test_login_required_redirects_anonymous_reader asserts only that the redirect contains next= and never follows the journey through the form, which is why CI is green. Fix: render the action as url_for('auth.login', next=request.args.get('next')), or carry next as a hidden field — keeping the existing _is_safe_local_redirect() check on the way out. (webadmin/logs.py#L298)

Notes (non-blocking)

  • The new teardown test passes against the un-fixed code, so it does not protect the fix it was written for. Against the pre-fix video.cpp/videortsp.cpp, test_backend_that_dies_before_binding_is_a_failed_start correctly fails, but test_backend_that_dies_after_binding_is_torn_down_and_slot_reused passes — consistently, over five runs. Its assertions can be satisfied by the hangup path (which reaches close_rtsp() with pid_ still set) rather than the reap()-in-tick() path the media_fd() guard exists for. To pin the guard, assert the thing the guard changes: that the media fd count returns to its pre-publish value, or that only one backend is ever started for one publisher.
  • MSG_TRUNC turns a partially-delivered oversized MPEG-TS datagram into a fully-dropped one. Right for RTP, a small trade for TS: a >2048-byte datagram used to deliver its first 2048 bytes and resynchronise (glitchy but moving), and now delivers nothing. Both are broken and the counter makes it diagnosable, which is the improvement. If you would rather it not arise, a 65535-byte buffer costs one stack page here and makes truncation unreachable.
  • A cold pass claimed RTP/HEVC fails for a B-frame stream; it did not reproduce. The claim was "first pts and dts value must be set" on ffmpeg 8.0.1. On ffmpeg 7.0.2 with clips differing only in bframes=0 vs bframes=4, both started a backend and both recorded a segment (159 612 and 147 956 bytes), no errors. Recording it as not-reproduced rather than dropping it, since their ffmpeg was a major version ahead of mine.
  • Three pre-existing defects the lifecycle pass surfaced, none touched by this PRvideorec.cpp is not in the diff, and the backpressure logic is unchanged from main: the forked fsync() helper's pid is discarded and never reaped (videorec.cpp:251); that helper inherits the listening sockets and epoll fd and closes only the recording fd, so a re-fork overlapping it can fail its bind and leave tcp_fd == -1 with no retry (videorec.cpp:252); and the RTSP backpressure gate leaves EPOLLIN armed while not reading (video.cpp:1425), the same readiness-masking shape as the detect spin above. Happy to raise these separately.

Verified clean

Tree builds clean under the project's full -Werror set. stop() after reap() cannot signal pid -1. The cleanup() idempotence guard is safe as written — both call sites run in the same request thread (Werkzeug closes the app iterator, then runs call_on_close), so the non-atomic done check cannot double-release the BoundedSemaphore; exercised GET, HEAD, client abort, an exception mid-stream, and spawn failure. RTP classification resets correctly across successive publishers. Local webadmin suite: 286 passed (3 remaining failures are my sandbox's static ffmpeg producing no output — their permit-accounting assertions all pass). CI green at this head, re-checked immediately before posting.

Detect leaves an incomplete RTSP or HTTP request line in the socket on
purpose, since the credential may be in the next segment. With
level-triggered EPOLLIN those bytes re-fire on every epoll_wait until
the 2 s detect timeout, which measured as most of a core. The viewer
now says when it is holding bytes and the child arms EPOLLET for that
fd until it is classified, so it only wakes when more arrive.

Also: the UDP receive buffer is the full datagram size, so MSG_TRUNC
drops are unreachable in practice, and the RTP lifecycle test checks
the child's fd count does not grow across backend restarts.
require_log_read redirected to /login?next=..., but the form posted to
a bare /login and login() reads next from the query string, so a
Login Required share link always landed on the index.
The session fixture returns on "listening", which is logged before
"video child N ready"; on a loaded CI runner the pid was not in the
log yet and the CPU-spin test failed on a missing match.
@tridge
tridge merged commit 5b7064f into ArduPilot:main Sep 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AIReview Request an automated AI review; picked up by the reviewprs sweep MargeOnCIPass

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant